Day 13 到 15 已完成 AI 生成計畫與使用者操作計畫的功能,但計畫生成後會直接生效。Day 15 的 PUT /plans/{plan_id} 甚至允許把 status 改成任何字串,沒有轉換規則。
AI 生成的計畫可能有時數估算或格式問題。計畫從生成到使用者實際執行之間,需要讓使用者查看、停止或要求調整的流程,這就是「人在迴路」(human-in-the-loop):AI 生成草案,使用者負責把關。
今天會用狀態機取代 Day 15 可任意修改狀態的做法。
草稿(draft)
│
│ GET /plans/{id}/review(第一次查看,自動轉換)
▼
審核中(under_review) ──── POST /plans/{id}/feedback ────┐
│ │
│ PUT /plans/{id}/status,action=核准 │ 重新生成後
▼ │ 狀態留在審核中
已核准(approved) │ 等使用者再看一次
│
審核中(under_review)◄─────────────────────────────────────┘
│
│ PUT /plans/{id}/status,action=拒絕
▼
已拒絕(rejected)
已核准 和 已拒絕 都是終態,之後不能再變更。反饋只允許在「審核中」提出;重新生成後仍維持「審核中」,等待使用者再次確認。
PUT /plans/{plan_id} 要收斂Day 15 的 PlanUpdateRequest 讓呼叫者可以直接把 status 改成任何字串,包含 "已核准",這代表任何人都能繞過審核流程,直接把草稿標成已核准,人在迴路的設計形同虛設。今天把這個端點的職責收斂:只能改標題,狀態改變只交給今天新增的兩個專門端點,各自帶著自己的規則檢查,不再有第三條路可以繞過去。
檔案位置: backend/planner.py
狀態: 修改檔案(修改 Day13 的 generate_plan 函式簽名與 Prompt)
用途: 讓 Planner 能根據使用者的反饋調整計畫內容
依賴: 無新增依賴
def generate_plan(
level: str,
available_hours: float,
learning_topic: str,
goal_title: str,
feedback: str | None = None,
) -> LearningPlan:
"""根據使用者的程度、可用時間、想學的主題與目標,生成一份結構化學習計畫;
如果有帶反饋,代表這是根據上一版草案調整過的新版本"""
prompt = (
"你是一位學習教練,請根據以下資訊生成一份學習計畫:\n"
f"- 學習程度:{level}\n"
f"- 每週可用時間:{available_hours} 小時\n"
f"- 想學的主題:{learning_topic}\n"
f"- 目標:{goal_title}\n\n"
"請生成 4 到 8 週的計畫,每週訂一個主題,並拆出具體的每日任務。"
"每週所有任務的預估時數加總,不要超過使用者每週可用時間。"
)
if feedback:
prompt += f"\n\n使用者對上一版草案的意見:「{feedback}」\n請根據這個意見調整計畫內容。"
return _structured_llm.invoke(prompt)
feedback 預設是 None,Day 13 原本呼叫 generate_plan(...) 的地方不用修改,維持向下相容。
檔案位置: backend/schemas.py
狀態: 修改檔案(接續 Day 15 的內容,繼續往下加)
用途: 定義審核、反饋、狀態變更的請求與回應格式
依賴: pydantic
class PlanReviewResponse(BaseModel):
"""GET /plans/{plan_id}/review 回傳的草案內容"""
plan_id: int
title: str
status: str
duration_weeks: int
tasks: list[TaskOut]
class PlanFeedbackRequest(BaseModel):
"""POST /plans/{plan_id}/feedback 的請求格式"""
user_id: int
feedback: str = Field(min_length=1, description="對草案的意見,例如覺得太難、時間太少")
class PlanFeedbackResponse(BaseModel):
"""POST /plans/{plan_id}/feedback 的回應格式,回傳重新生成後的新版本"""
plan_id: int
status: str
title: str
duration_weeks: int
task_count: int
class PlanStatusUpdateRequest(BaseModel):
"""PUT /plans/{plan_id}/status 的請求格式"""
user_id: int
action: Literal["核准", "拒絕"]
class PlanStatusUpdateResponse(BaseModel):
"""PUT /plans/{plan_id}/status 的回應格式"""
plan_id: int
status: str
PUT /plans/{plan_id},拿掉改狀態的能力檔案位置: backend/schemas.py
狀態: 修改檔案(修改 Day15 的 PlanUpdateRequest)
用途: 移除status欄位,狀態變更改由今天的專門端點負責
class PlanUpdateRequest(BaseModel):
"""PUT /plans/{plan_id} 的請求格式,只能改標題。
狀態變更改由 GET /plans/{id}/review 和 PUT /plans/{id}/status 負責"""
user_id: int
title: str | None = None
檔案位置: backend/main.py
狀態: 修改檔案(修改 Day15 的 update_plan 函式內容)
用途: 拿掉更新status的分支
@app.put("/plans/{plan_id}", response_model=PlanUpdateResponse)
def update_plan(
plan_id: int, payload: PlanUpdateRequest, db: Session = Depends(get_db)
) -> PlanUpdateResponse:
"""更新計畫標題,狀態不再從這裡改"""
plan = _get_owned_plan(plan_id, payload.user_id, db)
if payload.title is not None:
plan.title = payload.title
db.commit()
db.refresh(plan)
return PlanUpdateResponse(plan_id=plan.id, title=plan.title, status=plan.status)
檔案位置: backend/main.py
狀態: 修改檔案
用途: 展示草案內容,第一次查看自動把狀態從草稿轉成審核中
依賴: schemas, models
先把新的 Schema 加進 Day 15 已經有的那段 import,改成:
from schemas import (
PlanDetailResponse,
PlanUpdateRequest,
PlanUpdateResponse,
TaskUpdateRequest,
TaskUpdateResponse,
PlanReviewResponse,
PlanFeedbackRequest,
PlanFeedbackResponse,
PlanStatusUpdateRequest,
PlanStatusUpdateResponse,
)
@app.get("/plans/{plan_id}/review", response_model=PlanReviewResponse)
def review_plan(
plan_id: int, user_id: int, db: Session = Depends(get_db)
) -> PlanReviewResponse:
"""展示計畫草案,第一次查看時把狀態從草稿轉成審核中"""
plan = _get_owned_plan(plan_id, user_id, db)
if plan.status == "草稿":
plan.status = "審核中"
db.commit()
db.refresh(plan)
tasks = db.query(Task).filter(Task.plan_id == plan_id).order_by(Task.day).all()
return PlanReviewResponse(
plan_id=plan.id,
title=plan.title,
status=plan.status,
duration_weeks=plan.duration_weeks,
tasks=tasks,
)
檔案位置: backend/main.py
狀態: 修改檔案
用途: 帶著使用者反饋,重新呼叫Planner生成新版本,並重新排程
依賴: schemas, models, planner, scheduler
@app.post("/plans/{plan_id}/feedback", response_model=PlanFeedbackResponse)
def submit_feedback(
plan_id: int, payload: PlanFeedbackRequest, db: Session = Depends(get_db)
) -> PlanFeedbackResponse:
"""根據使用者反饋重新生成計畫,取代舊版本的內容和任務"""
plan = _get_owned_plan(plan_id, payload.user_id, db)
if plan.status != "審核中":
raise HTTPException(
status_code=400,
detail=f"計畫目前狀態是「{plan.status}」,要先呼叫 GET /plans/{{id}}/review 進入審核中才能提供反饋",
)
profile = db.query(Profile).filter(Profile.user_id == payload.user_id).first()
goal = db.query(Goal).filter(Goal.id == plan.goal_id).first()
# 帶著反饋重新生成計畫內容
learning_plan = generate_plan(
level=profile.level,
available_hours=profile.available_hours,
learning_topic=profile.learning_topic,
goal_title=goal.title,
feedback=payload.feedback,
)
# 重新排程新版本的任務
raw_tasks = [task.model_dump() for task in learning_plan.tasks]
scheduled_tasks = reschedule_tasks(raw_tasks, profile.available_hours)
# 舊版本的任務整批刪除,換成反饋後重新生成的新任務
db.query(Task).filter(Task.plan_id == plan_id).delete()
for task in scheduled_tasks:
title = f"【複習】{task.title}" if task.is_review else task.title
db.add(
Task(
plan_id=plan.id,
day=task.day,
title=title,
estimated_hours=task.estimated_hours,
status="待做",
deadline=datetime.now() + timedelta(days=task.day),
)
)
plan.title = learning_plan.title
plan.duration_weeks = learning_plan.duration_weeks
plan.content = learning_plan.model_dump()
# 狀態留在審核中:新版本一樣需要使用者再確認一次,不會自動核准
db.commit()
db.refresh(plan)
return PlanFeedbackResponse(
plan_id=plan.id,
status=plan.status,
title=plan.title,
duration_weeks=plan.duration_weeks,
task_count=len(scheduled_tasks),
)
檔案位置: backend/main.py
狀態: 修改檔案
用途: 核准或拒絕計畫,只有審核中的計畫能做這個動作
依賴: schemas, models
@app.put("/plans/{plan_id}/status", response_model=PlanStatusUpdateResponse)
def update_plan_status(
plan_id: int, payload: PlanStatusUpdateRequest, db: Session = Depends(get_db)
) -> PlanStatusUpdateResponse:
"""核准或拒絕計畫,這是唯一能把計畫轉成終態的入口"""
plan = _get_owned_plan(plan_id, payload.user_id, db)
if plan.status != "審核中":
raise HTTPException(
status_code=400,
detail=f"計畫目前狀態是「{plan.status}」,只有「審核中」的計畫能核准或拒絕",
)
plan.status = "已核准" if payload.action == "核准" else "已拒絕"
db.commit()
db.refresh(plan)
return PlanStatusUpdateResponse(plan_id=plan.id, status=plan.status)
啟動後端伺服器(記得先啟用 venv):
uvicorn main:app --reload
用 Day 13 的 /plans/generate 生成一份新計畫,拿到 plan_id 後照順序測試:
1. 查看草案,確認狀態自動轉成審核中:GET /plans/{plan_id}/review?user_id=1,回應的 status 應該是 "審核中"。
2. 還沒審核就想核准,應該失敗:如果 plan_id 是全新生成、還沒呼叫過 review,直接呼叫 PUT /plans/{plan_id}/status:
{ "user_id": 1, "action": "核准" }
應該收到 400 計畫目前狀態是「草稿」,只有「審核中」的計畫能核准或拒絕。
3. 提交反饋:POST /plans/{plan_id}/feedback
{ "user_id": 1, "feedback": "每週的任務太滿了,我希望多留一點緩衝時間" }
呼叫要跑完整個 Planner + 排程流程,跟 Day 13 一樣可能要等 10-30 秒。回應的 status 應該還是 "審核中",task_count 可能跟第一版不一樣。
4. 正式核准:PUT /plans/{plan_id}/status
{ "user_id": 1, "action": "核准" }
回應的 status 應該變成 "已核准"。
5. 核准後再想改,應該失敗:再呼叫一次 POST /plans/{plan_id}/feedback 或 PUT /plans/{plan_id}/status,都應該收到 400,因為 "已核准" 是終態。
代表你跳過了 GET /plans/{id}/review 這一步。狀態機設計成一定要先「看過草案」(呼叫 review)才能進入審核中,這是刻意的:核准或拒絕之前,系統要確保使用者至少查看過一次內容,不能生成完直接核准。
會。submit_feedback 是把整個 plan_id 底下的任務刪掉重建,所以如果使用者已經開始執行、標記了幾個任務完成(Day 15 的 PATCH /tasks/{id}),反饋一送出這些記錄就會消失。這是今天刻意的限制:反饋只能發生在「審核中」狀態,而審核中代表使用者「還沒開始照著計畫做」,一旦核准開始執行,就不該再透過反饋機制整批重建任務。如果核准後想調整,應該用 Day 15 的單一任務操作(延期、標記跳過),不是回頭修改整份計畫。
拒絕代表使用者對這個目標方向本身不滿意,不只是任務排得不好,這種情況需要使用者重新想清楚要學什麼、目標是什麼,回頭呼叫 POST /plans/generate 開一個新的 Goal 和 Plan,而不是在這個已經被拒絕的 plan_id 上面繼續修修改改。讓「已拒絕」保持乾淨的終態,之後任何統計或列表要濾掉已拒絕的計畫時,邏輯也比較單純。
PUT /plans/{plan_id} 現在功能變少了,會不會浪費掉?不會,職責分工反而更清楚:PUT /plans/{plan_id} 專心處理「跟狀態機無關」的欄位(目前只有標題,以後如果加其他一般性欄位也放這裡),狀態變更全部收斂到今天這兩個有規則檢查的專門端點。如果兩邊都能改狀態,等於留了一個後門可以繞過審核流程,這是 Day 15 當時還沒設計狀態機、暫時放寬的做法,今天狀態機做出來了,就要把後門補上。
今天加入計畫審核程序:草案生成後先由使用者查看,可提出反饋讓系統重新生成,最後由使用者核准或拒絕。Day 15 可任意修改狀態的問題也已收斂,狀態變更必須經過規則檢查。
系統現在是這樣的:
Day 1 ✓ 產品定義完成
Day 2 ✓ 開發環境準備
Day 3 ✓ 專案架構設計
Day 4 ✓ 資料庫設計
Day 5 ✓ SQLite 資料庫建置
Day 6 ✓ FastAPI 基礎
Day 7 ✓ 使用者檔案 API
Day 8 ✓ 理解 LLM Agent 的本質
Day 9 ✓ 連接 Ollama 本機模型
Day 10 ✓ LangGraph 最小範例
Day 11 ✓ Thread 與 State 管理
Day 12 ✓ 簡化的意圖路由
Day 13 ✓ 計畫生成 Agent
Day 14 ✓ 簡化的排程邏輯
Day 15 ✓ 計畫 CRUD API
Day 16 ✓ 計畫審核與用戶反饋(今天)
Day 17 ⬜ Daily Coach - 每日建議
計畫現在有了完整的生命週期:生成、審核、反饋、核准。但核准之後呢?使用者今天起床,要自己想起來去查計畫、查任務,系統完全是被動的,只會等使用者來問。明天要做 Daily Coach,讓系統主動整理「今天該做什麼」,加上一句激勵訊息,變成使用者早上起床第一個看到的陪伴,不再只是一個被動回答問題的 API。